package org.cirrostratus

import java.security.*
import javax.crypto.*
import javax.crypto.spec.*
import java.text.*

/**
 * Ec2Ligth class is intended to use as a lightweight client to amazons ec2 webservice.
 * Its based on meta programming, so don't look for methods for each ec2 operation,
 * instead you can use any operation listed at AWS official api doc (http://docs.amazonwebservices.com/AWSEC2/latest/APIReference/OperationList-query.html).
 *
 * <br/>
 * To pass your credentials to ec2 clinet you have 2 ways:
 * <ul>
 * <li> use the constructor: ec2 = mew Ec2Light('accesKey', 'secretKey')
 * <li> set environment variables: <b>AWS_ACCESS_KEY_ID</b> and <b>AWS_SECRET_ACCESS_KEY</b> and use the default constructor without any parameter
 * </ul>
 * 
 * Once you have an ec2 client instance, you can just simple call any ec2 operation:
 * <pre>
 * groovy:000> ec2 = new org.cirrostratus.Ec2Light()
 * ===> org.cirrostratus.Ec2Light@6a4e4eec
 * groovy:000> ec2.DescribeSecurityGroups()
 * ===> {ownerId=820212345678, groupName=default, groupDescription=default group}
 * </pre>
 * 
 *  If you use the method syntax you will get back an Expando or Expando list with the flattened xml.
 *  Or in groovyshell you can use the operation names as meta property names, to get a nice printing table
 *  with headers:
 *  <pre>
 *  groovy:000> ec2.DescribeSpotInstanceRequests  
 *&nbsp;SPOTINSTANCEREQUESTID&nbsp;SPOTPRICE&nbsp;TYPE&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;STATE&nbsp;&nbsp;CREATETIME&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;PRODUCTDESCRIPTION&nbsp;
 *&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;sir-6531ac07&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;0.1&nbsp;one-time&nbsp;failed&nbsp;2010-05-27T18:03:19.000Z&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;Linux/UNIX&nbsp;
 *&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;sir-ad57ee07&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;0.1&nbsp;one-time&nbsp;closed&nbsp;2010-06-03T09:11:03.000Z&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;Linux/UNIX&nbsp;
 * </pre>
 * @author lalyos
 * @see <a href="http://docs.amazonwebservices.com/AWSEC2/latest/APIReference/OperationList-query.html">AWS official api doc</a>
 */
class Ec2Light {
    static  EC2_URL = 'ec2.amazonaws.com'
    static final HTTP_METHOD = 'GET'
    static final SIGN_VERSION = '2'
    static final SIGN_METHOD = 'HmacSHA1'
    static  AWS_VERSION = '2009-10-31'
    static final SDF = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'")
    static final SDF2 = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.000'Z'")
    static final GMT = TimeZone.getTimeZone('GMT')
    static debug = false
    static trace = false
    static warning = true

    static  methodMap = [din: [method:'DescribeInstances', root: 'reservationSet.item.instancesSet.item' , fields:['instanceId', 'amiLaunchIndex', 'imageId', 'launchTime', 'keyName', 'instanceState.name', 'ipAddress', 'placement', 'instanceType']], 
                            dim:[method: 'DescribeImages', root:'imagesSet.item'], 
                            dvol: [method: 'DescribeVolumes', root:'volumeSet.item', fields:['volumeId', 'snapshotId', 'size', 'availabilityZone', 'status', 'createTime', 'attachmentSet']],
                            dsnap: [method: 'DescribeSnapshots', root:'snapshotSet.item', fields:['snapshotId', 'status', 'ownerId', 'description', 'progress']],
                            daddr: [method: 'DescribeAddresses', root:'addressesSet.item', fields:['publicIp', 'instanceId'], reqParamNameBase: 'PublicIp'],
                            dkey: [method: 'DescribeKeyPairs', root:'keySet.item',reqParamNameBase: 'KeyName'],
                            addvol: [method: 'CreateVolume', root:'ROOT'],
                            attvol: [method: 'AttachVolume'],
                            delvol: [method: 'DeleteVolume', reqParamNameBase: 'VolumeId'],
                            detvol: [method: 'DetachVolume', root:'ROOT', reqParamNameBase: 'VolumeId'],
                            run: [method: 'RunInstances', root: 'instancesSet.item', fields:['instanceId', 'amiLaunchIndex', 'imageId', 'launchTime', 'keyName', 'instanceState.name', 'ipAddress', 'placement', 'instanceType']],
                            kill: [method: 'TerminateInstances', reqParamNameBase: 'InstanceId', root:'instancesSet.item'],
                            assocaddr: [method: 'AssociateAddress'],
                            disaddr: [method: 'DisassociateAddress', reqParamNameBase:'PublicIp'],
                            reladdr: [method: 'ReleaseAddress', reqParamNameBase:'PublicIp'],
                            ]
    static final xmlMapper = [  placement: {it.availabilityZone.text()},
                                instanceState: {it.name.text()},
                                productCodes: {it.item.productCode.collect{it.text()}},
                                monitoring: {it.state.text()},
                                attachmentSet: {"${it.item.instanceId.text()}:${it.item.device.text()}" as String},
                                shutdownState: {it.name.text()},
                                previousState: {it.name.text()},
                                Attribute: {it.Name.text()},
                                ]
    
    def signingKey
    def mac
    
    public static keypathPrefix = System.getProperty('user.home') + '/.ec2/id-'
    
    static {
        SDF.setTimeZone(TimeZone.getTimeZone("Etc/UTC"))
        
        ArrayList.metaClass.pr={
            def fieldSizeMap = [:]
            
            if (delegate) {
                delegate[0].properties.each{fieldSizeMap.put(it.key, it.key.split(/\./)[0].length())}

                delegate.each{
                    it.properties.each{k,v -> if (v?.size() > fieldSizeMap[k]) {fieldSizeMap[k] = v?.size()} }
                }

                delegate[0].prHead(fieldSizeMap)
                delegate.each{it.prSingle(fieldSizeMap)}
            } else {
                println '-empty-result-'
            }
            ''
        }
        Expando.metaClass.pr={[delegate].pr()}
        
        Expando.metaClass.prTidy={fieldSizeMap=[:], printClosure ->
            // by default print all properties
            def fields = delegate.properties
            if (delegate.respondsTo('prFields')) {
                def fieldsToPrint = delegate.prFields()
                //println "[debug] fieldsToPrint=$fieldsToPrint"
                //println "[debug] delegate.dump(): ${delegate.dump()}"
                //println "[debug] delegate.prFields(): ${delegate.prFields()}"
                
                if (fieldsToPrint) {
                    fields = delegate.properties.findAll{it.key in fieldsToPrint}
                }
            }
            fields.each{printClosure(it)}
            println ''            
        }
        
        Expando.metaClass.prxxx={fieldSizeMap=[:], printClosure ->
            println "[debug prxxx] delegate.dump(): ${delegate.dump()}"
            println "[debug prxxx] delegate.prFields(): ${delegate.prFields()}"
            println "[debug prxxx] delegate.prFields(1): ${delegate.prFields(1)}"
            println "[debug prxxx] delegate.respondsTo('prFields'):"
                        delegate.respondsTo('prFields').each{println it}
            
            def hack=delegate.respondsTo('prFields')[0].invoke(delegate,null)
            println "[debug prxxx] delegate.respondsTo('prFields')[0].invoke(delegate,null)= $hack"
            
            def fieldsToPrint = delegate.prFields()
            println "[debug prxxx] fieldsToPrint: ${fieldsToPrint}" 
        }
        
        Expando.metaClass.prHead={ fieldSizeMap=[:] ->
            delegate.prTidy(fieldSizeMap){
                def shortName = it.key.split(/\./)[0]
                print shortName.toUpperCase().padRight(fieldSizeMap.get(it.key,0))
                print ' '                
            }
        }
        
        Expando.metaClass.prSingle={ fieldSizeMap=[:]->
            delegate.prTidy(fieldSizeMap){
                try {
                  print "$it.value".padLeft(fieldSizeMap.get(it.key,0))
                } catch(Exception e) { print 'EXC'}
                print ' '
            }
        }

        final ant = new AntBuilder()
        
        Expando.metaClass.ssh={cmd, user='root', timeout=0->
            println "executing: $cmd (as: $user)"
            if (delegate.ipAddress && delegate.keyName) {
                def privateKey = Ec2Light.keypathPrefix + delegate.keyName
                ant.sshexec(host:delegate.ipAddress, keyfile:privateKey, username:user, command:cmd, trust:true, output:'ssh_resp.delme', timeout:timeout)
                return new File('ssh_resp.delme').text
            }
        }
    }
    
    def accesKey
    def secretKey

    private static debug(msg) {
        if (debug)println '[Ec2Light][debug] ' + msg
    }

    static trace(msg) {
        if (trace)println '[Ec2Light][trace] ' + msg
    }

    static warning(msg) {
        if (warning)println '[Ec2Light][WARNING] ' + msg
    }

    def Ec2Light(accesKey = System.env.AWS_ACCESS_KEY_ID, secretKey = System.env.AWS_SECRET_ACCESS_KEY) {
        this.accesKey = accesKey
        this.secretKey = secretKey
        
        if (this.accesKey == null || this.secretKey == null){
            throw new Exception('please either provide AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY as environment variables, or as constructor arguments.')
        }
        signingKey = new SecretKeySpec(secretKey.getBytes(), SIGN_METHOD)
        mac = Mac.getInstance(SIGN_METHOD)
        mac.init(signingKey)
    }
    
    private def getDefaultParams() {
        return [
          AWSAccessKeyId : accesKey,
          SignatureMethod : SIGN_METHOD,
          SignatureVersion : SIGN_VERSION,
          Version : AWS_VERSION,
          Expires : getExpires()
          //Expires : getExpiresAsSeconds()
          //Timestamp : getTimestamp()
        ]
    }
    
    private def getExpires() {
        def cal = new GregorianCalendar(GMT)
        cal.add(Calendar.MINUTE, 10)
        //SDF.format(cal.getTime())
        SDF2.format(cal.getTime())  
    }
    
    private def String getExpiresAsSeconds() {
        def cal = new GregorianCalendar(GMT)
        cal.add(Calendar.MINUTE, 10)
        //SDF.format(cal.getTime())
        SDF2.format(cal.getTime())
        def time = cal.getTime().time / 1000 as Integer
    }
        
    private def String getTimestamp() {
    }
    
    def methodMissing(String name, args) {
        debug "methodMissing: $name args.size=${args.size()} args=$args (${args.getClass()} )"
        if (debug) args.eachWithIndex{arg,idx -> println "\t$idx: $arg [${arg.getClass()}]"}

        // first try to find by short name
        def ec2method = methodMap[name]
        if (ec2method == null) {
            //lets try to find by long name
            ec2method = methodMap.find{name.equalsIgnoreCase(it.value.method)}?.value
            if (ec2method == null) {
                // if its still not found just try to use the missing methodname 
                // as an AWS action
                ec2method = [method:name]
            }
        }

        def optionalParams = createOptionalParams(args, ec2method)
        
        def methodName = ec2method['method']
        try{
            def response = call(methodName, optionalParams)
            trace(response)
            return parseResponse(response,ec2method);
        } catch (Throwable t){
            t.printStackTrace()
        }
    }

    private def createOptionalParams(args, ec2method) {
        def optionalParams = null
        if (args) {
            def methodName = ec2method['method']
            def reqParamNameBase = ec2method['reqParamNameBase']
            if (!reqParamNameBase) {
                if(methodName.startsWith('Describe')) {
                    reqParamNameBase = methodName -'Describe'- ~/s$/ + 'Id'
                }
            }
            //one argument was passed
            if (args.size() == 1) {
                // if the only parameter is a map, we pass it through
                if (args[0] instanceof Map) {
                    debug 'one arg call with a Map'
                    // convert all value to string so you can pass integers like: Size:10 instead of Size:'10'
                    optionalParams = [:]
                    args[0].each{k,v -> optionalParams.put(k,"$v")}
                } else if (args[0] instanceof Expando){
                    debug "single arg is an Expando, trying to get its '${reqParamNameBase}' property"
                    optionalParams = [:]
                    // convert 'InstanceId' to 'instanceId'
                    def propertyName =  reqParamNameBase[0].toLowerCase() + reqParamNameBase[1..-1]
                    def paramValue = args[0]."$propertyName"
                    debug "paramValue=$paramValue"
                    optionalParams = [ ("${reqParamNameBase}.1" as String) : paramValue]
                } else {
                    debug "one arg call with a singleParam reqParamNameBase:${reqParamNameBase}"
                    if (reqParamNameBase) {
                        optionalParams = [ ("${reqParamNameBase}.1" as String) : args[0]]
                    } else {
                        debug "we don't know what to do with a single param :${args[0]} for the method: ${methodName} so lets' ignore it for now ..."
                    }
                }
            }
        }
        debug "optionalParams=$optionalParams"
        return optionalParams
    }
    
    private def parseResponse(response, ec2method){
        def ret
        def rootNode = new XmlParser().parseText(response)            
        
        // if root node is digged deep get is (root: 'reservationSet.item.instancesSet.item')
        def rootNodeName = ec2method['root']
        if (rootNodeName) {
        	if (rootNodeName == 'ROOT') {
        		// we want the ROOT node so nothing else to do
        	} else {
        		rootNode = rootNodeName.split(/\./).inject(rootNode){node,next -> node."$next"}
        	}
        } else {
            //rootnode is the next sibling of requestId
            def firstChild = rootNode.children()[0]
            if (firstChild.name().localPart == 'requestId') {                
                def secondChild = rootNode.children()[1]
                if (secondChild.children()[0].getClass() != String) {
                    rootNode = secondChild.children()
                }
            } else {
                rootNode = firstChild
            }
        }
        
        if (rootNode instanceof Node) {
            debug "response xml is NODE"
            ret = createExpandoFromNode(rootNode, ec2method)
        } else {
            debug "response xml is NODE_LIST"
            ret = []
            rootNode.each{ nextNode ->
                ret << createExpandoFromNode(nextNode, ec2method)
            }
            
            // if list has only 1 elemnt unwrap it
            if (ret.size()==1) {ret = ret[0]}
        }
        return ret
    }
    
    private def createExpandoFromNode(rootNode,ec2method) {
        debug "createExpandoFromNode() called with ${rootNode.name()}"
        def expandoFields = [:]
        rootNode.children().each{
            if (it.getClass() == String) {
                expandoFields.put(rootNode.name().localPart,it)
            } else {
                def propName = it.name().localPart
                //println "\tnext child:"
                it?.dump()
                def attr = it.children()[0]
                //println "\t\t attr:$attr [${attr.getClass()}] "
                if (attr instanceof String || attr == null) {
                    debug "simple attr found: $propName"
                    expandoFields.put(propName, it.children()[0])
                } else {
                    debug "!!!!!! compound attr os found: $propName"
    
                    def mapper = xmlMapper[propName]
                    if (mapper) {
                        //debug "!!!! mapper found:"
                        def propValue= mapper(it)
                        //debug "!!!! calculated VALUE: $value"
                        expandoFields.put(propName, propValue)
                    }
                }
            }
        }
        
        debug "expandoFields:$expandoFields"
        def ret = new Expando(expandoFields)
        ret.metaClass.prFields={ec2method?.fields.collect{it.split(/\./)[0]}}
        return ret
    }

    private def propertyMissing(String name) {
        //debug "propertyMissing: $name"
        def response = this."$name"()
        if (response) {
            if (! (response instanceof List)) {
                debug "WRAP IT for nice printing"
                response = [response]
            }
            response?.pr()
            return ''
        } else {
            throw new MissingPropertyException("property '$name' not found by Ec2Light ...")
        }
    }
    
    def waitForSomething(somethingId, propertyName, requiredValue, ec2Operation, timeout=300, pollPeriod=15) {
        def ellapsedTime = 0
        
        def response=this."$ec2Operation"(somethingId)
        def actualState = response."$propertyName"
        while (actualState != requiredValue && ellapsedTime < timeout) {
            warning "actualState=$actualState requiredValue=$requiredValue  ellapsedTime=$ellapsedTime timeout=$timeout pollPeriod=$pollPeriod"
            ellapsedTime += pollPeriod
            sleep 1000*pollPeriod
            actualState = this."$ec2Operation"(somethingId)."$propertyName"
        }
        if (ellapsedTime == timeout) {
            def msg = "we were waiting for $propertyName:'$requiredValue' for $timeout times sleeping $pollPeriod sec inbetween, but it's still '$actualState' ... "
            warning msg
            throw new Exception(msg)
        }
    }
    
    def waitForInstanceState(instanceReference, stateName, timeout=300, pollPeriod=15) {
        if (instanceReference instanceof String) {
            // instanceReference is instanceId
            waitForSomething(instanceReference, 'instanceState',stateName, 'din', timeout, pollPeriod)
        } else if (instanceReference.instanceId) {
            // instanceReference is an Expando object and has a property of 'instanceId'
            waitForSomething(instanceReference.instanceId, 'instanceState',stateName, 'din', timeout, pollPeriod)            
        } else {
            warning "i don't know how to get instanceId from: $instanceReference (${instanceReference.getClass})"
        }
    }
    
    private def call(action,optionalParams=[:]) {
        debug "call action:${action} optionalParams:${optionalParams}"
        
        def params = getDefaultParams()
        if (optionalParams) {
            params += optionalParams
        }
        params.put('Action', action)
        
        debug "params: $params"
        def sortedParams = params.keySet().sort().collect{
            def nextValue= params[it]
            def encodedValue = URLEncoder.encode(params[it])
            encodedValue=encodedValue.replaceAll('\\+', '%20')
            "${it}=${encodedValue}"
        }
        debug "sortedParams=$sortedParams"
        
        def strToSign = "${HTTP_METHOD}\n${EC2_URL}\n/\n${sortedParams.join('&')}"
        debug "strToSign=$strToSign"
        def signature = URLEncoder.encode(mac.doFinal(strToSign.getBytes()).encodeBase64().toString())
        
        def queryUrl = "https://${EC2_URL}/?${sortedParams.join('&')}&Signature=${signature}"
        
        def conn
        try {
            conn = new URL(queryUrl).openConnection()
            return conn.getInputStream().text
            } catch (Exception e) {
                def errorXml = conn.getErrorStream().text
                warning errorXml
                def errorMessage = new XmlParser().parseText(errorXml).Errors.Error.Message.text()
                throw new Exception(errorMessage)
            }
    }
}